iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Modern Web

用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)系列 第 26

要支援多語系時,路由與內容怎麼組織才不會失控?

  • 分享至 

  • xImage
  •  

多語系網站要先決定 URL,再開始翻內容。Astro 的 i18n config 可以定義預設語系、URL
prefix 與缺頁 fallback,但它不會替你判斷「這個頁面到底有沒有翻譯」。route、語言 metadata 和 language
picker 若沒有共用同一份翻譯可用狀態,英文網址可能顯示中文,picker 也可能把讀者送進 404。

這篇會在現有內容站加入繁中與英文 route,並保留前 26 篇文章的 /blog/... URL。版本基準是 Astro
7.1.1;官方文件查證與瀏覽器實測日期為 2026-07-24。

多語系先從 URL 結構開始

這個網站在 Day 26 之前只有一種語言:

頁面 route
  /
  /blog
  /blog/page/1
  /blog/day-25-view-transitions

內容
  src/content/blog/day-*.md

語言 metadata
  <html lang="zh-Hant">
  og:locale = zh_TW
  JSON-LD inLanguage = zh-Hant
  RSS language = zh-tw

「缺一個英文資料夾」只涵蓋檔案結構,既有 URL 還被很多地方使用。Day 3 的 file-based routing決定頁面位置,Day 15 的動態 route用文章 ID 產生公開網址,Day 20 的 RSS 與 JSON endpoint也把
/blog/... 發給外部程式。文章正文裡還有一批已發布內鏈。

翻文案前,要先決定既有繁中 URL 是否全部改成 /zh-tw/...

預設語系不加 prefix,沿用既有 URL

這次把繁中設為 default locale,英文使用 /en/

// astro.config.mjs
export default defineConfig({
  i18n: {
    locales: ["zh-tw", "en"],
    defaultLocale: "zh-tw",
    routing: {
      prefixDefaultLocale: false,
    },
  },
});

prefixDefaultLocale: false 代表:

  • default locale 的頁面留在 src/pages/,網址不加語系。
  • 其他語系放進對應資料夾,例如 src/pages/en/,網址會加 /en/
  • /zh-tw/ 不會成為第二份繁中首頁。本次 production preview 實測回傳 404。

設定後的路徑如下:

內容 繁中 English
首頁 / /en/
i18n demo /demos/i18n /en/demos/i18n
文章列表 /blog 尚無翻譯
文章 /blog/day-... 尚無翻譯

另一種做法是 prefixDefaultLocale: true,讓繁中與英文都帶 prefix:

策略 URL 好處 成本
default 不加 prefix /blog/.../en/... 保留既有 URL;default 最短 兩種 URL 結構不完全對稱
全部加 prefix /zh-tw/.../en/... route 結構對稱 舊 URL 要 redirect;canonical、feed 與內鏈都得遷移

如果是還沒上線的新站,兩種策略都能成立。這個 capstone 已經用 /blog/...
作為 canonical,沿用既有路徑比 route 形式對稱更實用。這和
Day 16 的 redirect/rewrite是同一類問題:URL 一旦發布,就成為外部契約。

Page route 直接對應資料夾結構

設定 prefixDefaultLocale: false 後,頁面拓撲直接對應 URL:

src/pages/
├── index.astro
├── demos/
│   └── i18n.astro
└── en/
    ├── index.astro
    └── demos/
        └── i18n.astro

src/pages/demos/i18n.astro 是繁中,src/pages/en/demos/i18n.astro 是英文。兩個 route 共用 I18nRouteDemo.astro
的版面,但各自傳入語系內容。

繁中 i18n demo 顯示目前路徑、英文對應翻譯與 language picker

這裡的 page route 由資料夾結構決定,沒有使用 [lang] 動態 route,也沒有在既有 auth
middleware 裡自行拆 pathname。Day 23 的 middleware 與 locals已經負責每次 request 的登入狀態。官方 routing 無法表達產品規則時,才需要切到
routing: "manual",自行組合 i18n middleware;把兩種責任放進同一支 middleware,locale
redirect 和 session 查詢都會變得更難測。

Language picker 要先知道翻譯存不存在

Astro 提供 getRelativeLocaleUrl(),會依 astro.config.mjs 產生符合 prefix 策略的網址:

---
import { getRelativeLocaleUrl } from 'astro:i18n';

const englishURL = getRelativeLocaleUrl('en', 'demos/i18n'); // /en/demos/i18n/
---

<a href="{englishURL}" hreflang="en" lang="en">English</a>

使用 helper 比自己串 `/en/${pathname}` 穩定。若將來 default prefix 或 locale path
mapping 改變,呼叫端不用各自改字串。

但 helper 只保證 URL 格式正確,不保證頁面存在。getRelativeLocaleUrl('en', 'blog') 可以產生
/en/blog/,目前專案卻沒有這條 route。

因此,專案另外維護一份已翻譯 route:

const translatedRouteKeys = new Set(["", "demos/i18n"]);

export function hasTranslatedRoute(routeKey: string): boolean {
  return translatedRouteKeys.has(routeKey);
}

picker 先把當前 pathname 正規化成不含 locale 的 route key,再查 availability:

const routeKey = getRouteKey(Astro.url.pathname); const translationAvailable = hasTranslatedRoute(routeKey); const
options = locales.map((locale) => ({ locale, href: translationAvailable ? getRelativeLocaleUrl(locale, routeKey) :
undefined, }));

結果是:

  • //en/ 可以互切。
  • /demos/i18n/en/demos/i18n 保留同一頁語意。
  • /blog/... 的 English 顯示 unavailable,不產生假連結。
  • 目前語系用 aria-current="page" 表示。

picker 是 server-rendered 的普通 <a>,沒有新增 Vue island,也沒有 picker 專屬 JavaScript。整站仍有
Day 25 加入的 ClientRouter,所以同站切換會走 client-side navigation;直接貼
/en/demos/i18n 給瀏覽器,也能載入完整 HTML。

UI 字串、page route、長文分三層管理

「內容怎麼組織」取決於 UI 字串、page route 與長文各自的需求,至少要拆成三層:

資料 放置位置 判準
導覽標籤、按鈕、短提示 locale dictionary 短、跨頁重用、key 穩定
頁面組裝與 route document src/pages/{locale}/ URL 和 file-based routing 的來源
部落格長文 Content Collection 需要 schema、查詢、日期、作者與翻譯關係

站名、Header、Footer 與 unavailable 提示放在 src/i18n/index.ts,兩組 demo page 則留在
src/pages/。既有 26 篇文章仍是繁中,所有 blog route 也維持原狀。

之後若加入文章翻譯,Day 10 建立的 Content Collection至少需要兩個欄位:

locale: zh-tw
translationKey: day-26-i18n-routing

locale 用來篩選列表、搜尋、RSS 與動態 route;translationKey
把同一篇的不同語言版本配在一起。公開 slug 可以依語言調整,但 translation key 應保持穩定。

day: 26 是系列排序,不能當翻譯 identity;未來若補一篇不屬於鐵人賽的文章,這種配對會立即失效。language
picker 應透過 translation key 查詢另一個 locale entry,不能靠「把 slug 加 /en/」猜譯文。

當語言來源不只一份:一個真實專案的三套查找

這個對照專案的語言資料分散在 route、翻譯查找與 language picker。

一個上線中的多語系品牌官網支援四個語系,astro.config.mjs 裡沒有 i18n 區塊。語系改由 src/pages/[...lang]/ 這個 rest
route 承接,路徑清單來自一份手寫的 getStaticPaths

// src/modules/util.js
const locales = ["en", "cn", "es", "pt"];

export async function getStaticPaths() {
  return locales.map((locale) => ({ params: { lang: locale } }));
}

根路徑另外用 Astro.rewrite() 導向預設語系,middleware 則自己從 pathname 切出語系字串。

專案動工時若還沒決定是否採用官方 i18n
routing,先用動態 route 支援四個語系,當下需要做的決定較少。後續成本是「同一件事有幾個來源」。這個專案並存三套翻譯查找方式:.astro
檔用一個自寫的 t(key) 查 JSON、Vue
island 用 vue-i18n、另有一個元件內建自己的字典。語言清單也有兩份,一份在 i18n 模組裡,另一份在 language
picker 元件裡各自硬寫。

沒有單一來源,缺 key 就不會明確失敗。這個專案的語系 JSON,英文有 89 個 key,其他三個語系各 83 個。查不到時,包裝函式會回傳字串
NONE

// 查不到就退回英文,英文也沒有就回傳 'NONE'
if (_get === key) return defText ? defText : "NONE";

因此,「這個語系少了六個字串」不會讓 build 失敗,而會變成正式頁面上的 NONE。language
picker 也不檢查翻譯是否存在,切換時直接改寫 window.location.href,讓頁面完整重載。

getRelativeLocaleUrl()
會計算 prefix,並把「網址怎麼組」集中成一個來源。再配一份明確的 availability 查詢,缺頁會顯示 unavailable,不會產生假連結。語言清單、<html lang>og:locale
集中成一份 mapping 也是同一個做法:新增語系時只改一個地方,不必搜尋還有哪些表沒有同步。

URL 變了,語言 metadata 也要一起變

原本 BaseLayout 把語言寫死成繁中。新增 /en/ 後如果不調整,畫面雖然是英文,產出的 metadata 仍會是:

<html lang="zh-Hant">
  <meta property="og:locale" content="zh_TW" />
</html>

瀏覽器、搜尋引擎與輔助科技會收到錯誤的語言訊號。專案用一份 mapping 定義 locale 在各協定中的格式:

const localeMeta = {
  "zh-tw": {
    htmlLang: "zh-Hant",
    ogLocale: "zh_TW",
  },
  en: {
    htmlLang: "en",
    ogLocale: "en_US",
  },
};

URL segment、HTML language tag 與 Open Graph
locale 的格式不一定相同。集中成一份 mapping 後,每個格式都有明確來源,也不會把同一個字串套進所有欄位。

BaseLayout 再依 Astro.currentLocale 更新:

  • <html lang>
  • og:locale
  • JSON-LD inLanguage
  • 英文頁的站名與預設 description
  • 有對應翻譯時的 rel="alternate"hreflang

英文 i18n demo 使用英文 route、英文文案與 active language picker

production preview 的 direct load 結果如下:

Route html lang canonical pathname og:locale JSON-LD inLanguage
/ zh-Hant / zh_TW zh-Hant
/en/ en /en/ en_US en
/demos/i18n zh-Hant /demos/i18n/ zh_TW zh-Hant
/en/demos/i18n en /en/demos/i18n/ en_US en

首頁和 demo 都有繁中、英文與 x-default alternate。/blog 沒有英譯,因此不輸出假的英文 alternate。RSS 也維持單一繁中
/rss.xml<language> 仍是 zh-tw,item link 仍指向
/blog/...;沒有文章內容時,先產一份空的英文 feed 沒有讀者收益。

Fallback、rewrite、redirect 分別改了什麼?

i18n 缺頁行為可以分成四種:

機制 何時發生 URL 是否改變 適用情境
default locale redirect 進入 / 所有語系都有 prefix,根路徑要導向 default
fallback redirect 某語系缺頁 會切到 fallback URL 希望讀者清楚知道改看另一種語言
fallback rewrite 某語系缺頁 不變 接受語系 URL 與畫面語言可能不同
不設 fallback 某語系缺頁 404 翻譯覆蓋率要明確,不隱藏缺頁

redirectToDefaultLocale 只有在 prefixDefaultLocale: true 時有意義,用來決定 / 是否導向
/{defaultLocale}。這個專案的繁中 route 沒有 prefix,所以不需要它。

fallback 則處理「某個語系缺少特定頁面」。Astro 7 的 fallbackType 預設是 redirect;設成 rewrite 時,static
build 會在原 locale URL 產出 fallback 內容,瀏覽器網址不變。

這個專案沒有設定 fallback,因為英文目前只有首頁與 i18n demo。若把 /en/blog/day-25-view-transitions
rewrite 成繁中文章,就會出現英文 URL、中文正文、英文 <html lang> 的矛盾;若改 metadata,又變成每個 fallback
route 都要判斷實際內容語言。

實測三個缺頁,結果都是 404:

/zh-tw/             404
/en/blog/           404
/en/demos/missing/  404

Fallback 仍有適用情境。翻譯覆蓋率高、少數頁面延遲上線時,redirect 到 fallback 語系可能比 404 友善;rewrite 則要確認 canonical、lang
與使用者提示都能誠實反映內容。先用表格判斷,再選 config,不要把 fallback 當「順手開著比較完整」。

要不要依瀏覽器語言自動轉址?

Astro 有 preferred locale 相關 API,也能從 request 的 Accept-Language
判斷瀏覽器偏好。不過「瀏覽器設定成英文」不等於「這次一定想看英文」:共用裝置、語言學習或系統預設都可能讓兩者不同。

Day
26 只提供手動 picker,不自動 redirect,也不把語言偏好寫入 cookie 或會員資料。讀者明確點了哪個語言,URL 就反映哪個語言。

若產品未來需要記住選擇,再把問題拆開:

  1. 第一次進站是否只做一次建議,不強制跳轉?
  2. 手動選擇是否優先於 Accept-Language
  3. 偏好存在 cookie、local storage,還是登入帳號?
  4. crawler 與預渲染 route 是否仍有穩定入口?

這四個問題確定後,再決定 middleware 如何處理偏好。

切換後,URL、內容和 metadata 有一起走嗎?

language picker 的驗收除了「點得動」,還要確認 URL、內容與 metadata 是否同步。本次從繁中 demo 切到英文,先在 window
放一個 marker,再檢查 Navigation Timing:

{
  "path": "/en/demos/i18n/",
  "lang": "en",
  "canonical": "/en/demos/i18n/",
  "og": "en_US",
  "jsonLd": "en",
  "active": ["i18n demo", "English"],
  "marker": "persists",
  "navigationEntries": 1
}

marker 保留、document navigation
entry 仍是 1,表示這次由 ClientRouter 接手,沒有完整 reload;URL、H1、canonical、語言 metadata 與 picker active
state 則全部換成英文。

接著按 back 回 /demos/i18n/lang 恢復 zh-Hant,active 變回繁體中文;forward 再回英文,狀態仍一致。direct
load 另外用新 browser session 驗過,沒有依賴「必須先從繁中點過來」。

行動版在 390×844 實測兩種語系:

{
  "innerWidth": 390,
  "scrollWidth": 390,
  "overflow": false,
  "pickerVisible": true
}

production build 產出 /demos/i18n//en/
/en/demos/i18n/;原本的 blog、RSS、搜尋與文章 route 仍能預渲染。整輪 language switch、back/forward 與 direct
load 的 browser console errors、page errors 都是 0。

今日驗收

  • Astro 7.1.1 production build 成功,新增繁中與英文 locale routes。
  • default locale 保留無 prefix URL;/zh-tw/ 不產生重複首頁。
  • language picker 只連存在的翻譯;/blog 的 English 顯示 unavailable。
  • /en/blog/ 未被 fallback 成中文,實際回 404。
  • <html lang>、canonical、Open Graph、JSON-LD 與 hreflang 隨 route 切換。
  • ClientRouter 切語系後 back/forward 正常,direct load 也成立。
  • 390px 沒有水平溢位,picker 可操作。
  • RSS 維持繁中 feed,既有 /blog/... item links 未改。
  • console errors 與 page errors 都是 0。

route、內容 identity 與 picker 若各自維護規則,就可能出現不一致。這個實作先沿用已發布 URL,再把短 UI 字串、page
route 與長文 collection 分開管理。language
picker 只替確實存在的翻譯產生連結;加入第二種語言後,缺頁會明確失敗,不會被 fallback 隱藏。

Day 27 會接著拆解 astro.config 裡的 integrations、prefetch 與 dev
toolbar:它們各自改變哪一層行為,以及哪些設定真的需要。

官方查證:Internationalization guideConfiguration reference:i18nastro:i18n module


上一篇
多頁網站想要 SPA 般的轉場,不寫一堆 JS 做得到嗎?
下一篇
astro.config 裡的 integrations 到底在做什麼?該裝哪些?
系列文
用 Astro 打造 Content-first 前端網站:30 天從靜態內容到會員、資料庫與選型(3rd)28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言